17.4 标记

并发标记分为两个步骤。

  • 扫描:遍历相关内存区域,依照指针标记找出灰色可达对象,加入队列。
  • 标记:将灰色对象从队列取出,将其引用对象标记为灰色,自身标记为黑色。

扫描

扫描函数gcscan_m启动时,用户代码和MarkWorker都在运行。

mgcmark.go

func gcscan_m() { // 重置扫描标志,返回所有goroutine数量 local_allglen:=gcResetGState()

// 并发执行扫描任务,不过此处仅使用当前线程执行(避免抢占用户代码和MarkWorker资源?) // 任务单元包括所有Root和goroutine stack useOneP:=uint32(1) parforsetup(work.markfor,useOneP,uint32(_RootCount+local_allglen),false,markroot) parfordo(work.markfor) }

const( _RootData =0 _RootBss =1 _RootFinalizers =2 _RootSpans =3 _RootFlushCaches=4 _RootCount =5 )

func gcResetGState() (numgs int) { // 初始化所有goroutine相关标志 // 这些标志对于避免重复扫描很重要 for_,gp:=range allgs{ gp.gcscandone=false //set to true in gcphasework gp.gcscanvalid=false //stack has not been scanned gp.gcalloc=0 gp.gcscanwork=0 } numgs=len(allgs) return }

parfor是一个并行任务框架(详见17.7节),其功能就是将任务平分,让多个线程各领一份并发执行。为保证整个任务组能尽快完成,它允许从执行较慢的线程偷取任务。

不过扫描函数仅使用了当前线程,并未启用并发方式执行,似乎后续版本另有变化。扫描目标包括多个ROOT区域,还有全部goroutine栈。

mgcmark.go

switch i{ case_RootData: … case_RootBss: … case_RootFinalizers: … case_RootSpans: … case_RootFlushCaches: if gcphase!= _GCscan{ // 将正在被cache使用的所有span全部上交central // 将缓存在cache的stack归还给所属span.freelist flushallmcaches() }

default: //parfor按顺序为每个任务提供一个Id,所以访问allgs数组时需要去掉Root gp:=allgs[i-_RootCount]

 // 收缩栈空间(此时不能执行用户代码,必须STW) 
if gcphase== _GCmarktermination{ 
    shrinkstack(gp) 
 } 

 // 调用scanstack->scanblock
 //scanstack会设置和检查gcscanvalid标志,避免重复扫描 
scang(gp) 

}

// 将当前队列上交给全局队列 gcw.dispose() }

所有这些扫描过程,最终通过scanblock比对bitmap区域信息找出合法指针,将其目标当作灰色可达对象添加到待处理队列。

mgcmark.go

func scanblock(b0,n0 uintptr,ptrmaskuint8,gcwgcWork) { // 遍历 for i:=uintptr(0);i<n; { bits:=uint32(addb(ptrmask,i/(ptrSize8)))

 // 没有标记,跳过 
if bits==0{ 
    i+=ptrSize*8
    continue
 } 

for j:=0;j<8&&i<n;j++ { 
     // 有bitPointer标记 
    if bits&1!=0{ 
         // 读取指针内容,目标对象地址 
        obj:= *(*uintptr)(unsafe.Pointer(b+i)) 

         // 确认指针合法 
        if obj!=0&&arena_start<=obj&&obj<arena_used{ 
            if obj,hbits,span:=heapBitsForObject(obj);obj!=0{ 
                 // 标记为灰色对象 
                greyobject(obj,b,i,hbits,span,gcw) 
             } 
         } 
     } 
    bits>>=1
    i+=ptrSize
 } 

} }

// 将尚未标记的对象标记为灰色,并放入队列 func greyobject(obj,base,off uintptr,hbits heapBits,spanmspan,gcwgcWork) { if hbits.isMarked() { return } hbits.setMarked() gcw.put(obj) }

此处的gcWork是专门设计的高性能队列,它允许局部队列和全局队列work.full/partial协同工作,平衡任务分配(详见17.7节)。

mgc.go

var work struct{ full uint64 //lock-free list of full blocks workbuf empty uint64 //lock-free list of empty blocks workbuf partial uint64 //lock-free list of partially filled blocks workbuf }

在markroot的最后,所有扫描到的灰色对象都被提交给了work.full全局队列。

标记

并发标记由多个MarkWorker goroutine共同完成,它们在回收任务开始前被绑定到P,然后进入休眠状态,直到被调度器唤醒。

mgc.go

func gcBgMarkStartWorkers() { // 为每个P绑定一个Worker for_,p:=range&allp{ if p.gcBgMarkWorker==nil{ go gcBgMarkWorker(p)

     // 暂停,确保该Worker绑定到P后再继续 
    notetsleepg(&work.bgMarkReady, -1) 
    noteclear(&work.bgMarkReady) 
 } 

} }

调度函数schedule从控制器gcController获取MarkWorker goroutine并执行。

proc1.go

func schedule() { if gp==nil&&gcBlackenEnabled!=0{ gp=gcController.findRunnableGCWorker(g.m.p.ptr()) }

execute(gp,inheritTime) }

控制器方法findRunnableGCWorker在返回当前P所绑定的MarkWorker时,会依据当前运行状态和相关策略设置工作模式,最后还负责将其唤醒。

MarkWorker有3种工作模式。

  • gcMarkWorkerDedicatedMode:全力运行,直到并发标记任务结束。
  • gcMarkWorkerFractionalMode:参与标记任务,但可被抢占和调度。
  • gcMarkWorkerIdleMode:仅在空闲时参与标记任务。

在了解基本运作流程后,我们去看看标记工作的具体内容。

mgc.go

func gcBgMarkWorker(p*p) { // 将当前goroutine绑定到P gp:=getg() p.gcBgMarkWorker=gp

// 唤醒外层创建循环 notewakeup(&work.bgMarkReady)

for{ // 休眠,直到被gcContoller.findRunnable唤醒 gopark(…, “mark worker(idle)”, …,0)

 // 只能在进入黑化阶段才能运行 
if gcBlackenEnabled==0{ 
    throw("gcBgMarkWorker:blackening not enabled") 
 } 

decnwait:=xadd(&work.nwait, -1) 
done:=false

 // 工作模式 
switch p.gcMarkWorkerMode{ 
case gcMarkWorkerDedicatedMode: 
     // 全力工作,直到全部任务结束 
    gcDrain(&p.gcw,gcBgCreditSlack) 

    done=true
    if!p.gcw.empty() { 
        throw("gcDrain returned with buffer") 
     } 
case gcMarkWorkerFractionalMode,gcMarkWorkerIdleMode: 
     // 在抢占或无法获取任务时退出 
    gcDrainUntilPreempt(&p.gcw,gcBgCreditSlack) 

    // 立即上交剩余缓存队列 
    if gcBlackenPromptly{ 
        p.gcw.dispose() 
     } 

    incnwait:=xadd(&work.nwait, +1) 
    done=incnwait==work.nproc&&work.full==0&&work.partial==0
 } 

 // 如果标记任务全部完成,则发送信号 
if done{ 
     // 该标志在截获bgMark1后才被设置,确保bgMark2在bgMark1之后发送 
    if gcBlackenPromptly{ 
        if work.bgMark1.done==0{ 
            throw("completing mark 2,but bgMark1.done==0") 
         } 
        work.bgMark2.complete() 
     }else{ 
        work.bgMark1.complete() 
     } 
 } 

} }

不同模式的MarkWorker对待工作的态度完全不同。

mgcmark.go

func gcDrain(gcw*gcWork,flushScanCredit int64) { for{ // 如果全局队列已空,且有等待的Worker,那么分出一部分任务 if work.nwait>0&&work.full==0{ gcw.balance() }

 // 反复尝试从本地或全局队列获取任务,直到所有Worker完成任务 
b:=gcw.get() 
if b==0{ 
    break
 } 

scanobject(b,gcw) 

} }

func gcDrainUntilPreempt(gcw*gcWork,flushScanCredit int64) { gp:=getg()

// 检查抢占标志 for!gp.preempt{ // 只要全局队列为空,就立即分出一部分任务,不关心是否有Worker进入等待状态 if work.full0&&work.partial0{ gcw.balance() }

 // 尝试从本地或全局获取任务,失败则放弃。不关心其他Worker是否完成任务 
b:=gcw.tryGet() 
if b==0{ 
    break
 } 

scanobject(b,gcw) 

} }

处理灰色对象时,无须知道其真实大小,只当作内存分配器提供的object块即可。按指针类型长度对齐,配合bitmap标记进行遍历,就可找出所有引用成员,将其作为灰色对象压入队列。当然,当前对象自然成为黑色对象,从队列移除。

mgcmark.go

func scanobject(b uintptr,gcw*gcWork) { hbits:=heapBitsForAddr(b) s:=spanOfUnchecked(b) n:=s.elemsize

for i=0;i<n;i+=ptrSize{ bits:=hbits.bits()

 // 标记位检查 
if i>=2*ptrSize&&bits&bitMarked==0{ 
    break  //no more pointers in this object
 } 
if bits&bitPointer==0{ 
    continue  //not a pointer
 } 

 // 读取指针内容,成员所引用对象地址 
obj:= *(*uintptr)(unsafe.Pointer(b+i)) 

 // 确认指针合法 
if obj!=0&&arena_start<=obj&&obj<arena_used&&obj-b>=n{ 
     // 将引用对象标记为灰色 
    if obj,hbits,span:=heapBitsForObject(obj);obj!=0{ 
        greyobject(obj,b,i,hbits,span,gcw) 
     } 
 } 

} }

在STW启动后,承担最终收尾工作的gcMark有点特殊。如果并发标记被禁用,那么它就需要完成全部的标记任务,回退到Go 1.4的阻塞工作模式。

mgc.go

func gcMark(start_time int64) { // 确保所有任务都上交到全局队列 gcFlushGCWork()

work.nproc=uint32(gcprocs())

// 并发执行扫描任务(这次不是单个线程了) // 因为已经STW,所以这次需要做flushallmcaches、shrinkstack操作 parforsetup(work.markfor,work.nproc,uint32(_RootCount+allglen),false,markroot) if work.nproc>1{ // 重置休眠标志 noteclear(&work.alldone)

 //parfor并发执行的关键 
helpgc(int32(work.nproc)) 

}

// 当前线程一起参加mark+drain任务 gchelperstart() parfordo(work.markfor) var gcw gcWork gcDrain(&gcw, -1) gcw.dispose()

// 休眠,等待gchelper任务结束后被唤醒 if work.nproc>1{ notesleep(&work.alldone) }

// 释放不再使用的stack缓存对象 freeStackSpans()

// 更新cache状态(被markroot处理过) cachestats()

// 计算下次回收阈值 memstats.next_gc= …(memstats.heap_reachable) * (1+gcController.triggerRatio))

// 不能小于最低阈值4 MB if memstats.next_gc<heapminimum{ memstats.next_gc=heapminimum }

minNextGC:=memstats.heap_live+sweepMinHeapDistance*uint64(gcpercent)/100 if memstats.next_gc<minNextGC{ memstats.next_gc=minNextGC } }

因为有gcController决策算法的参与,垃圾回收阈值next_gc变得更加灵活。

相比gcscan_m+MarkWorker,gcMark显然简单得多,关键问题就是gchelper如何执行。

1.函数helpgc唤醒足够数量的线程M用于执行parfordo任务。

2.被唤醒的M检查helpgc标志,执行gchelper函数完成mark+drain任务。

有关M执行方式,请参考本书后续“并发调度”相关内容。

proc1.go

func helpgc(nproc int32) { pos:=0

// 从1开始,因为当前线程(M)也参加并发任务 for n:=int32(1);n<nproc;n++ { // 跳过当前M正在使用的P if allp[pos].mcache== g.m.mcache{ pos++ }

 // 获取并设置M参数 
mp:=mget() 
mp.helpgc=n      // 关键 
mp.p.set(allp[pos]) 
mp.mcache=allp[pos].mcache

pos++ 

 // 唤醒M去执行任务 
notewakeup(&mp.park) 

} }

proc1.go

func stopm() { g :=getg()

retry: mput(g.m)

// 休眠 notesleep(&g.m.park) noteclear(&g.m.park)

// 被唤醒后,检查helpgc标志 if_g_.m.helpgc!=0{ // 执行gchelper函数 gchelper() } }

mgc.go

func gchelper() { g :=getg() gchelperstart()

// 执行mark+drain任务 parfordo(work.markfor) if gcphase!= _GCscan{ var gcw gcWork gcDrain(&gcw, -1) //blocks in getfull gcw.dispose() }

nproc:=work.nproc

// 如果全部任务(注意 -1)完成,那么唤醒GC线程 if xadd(&work.ndone, +1) ==nproc-1{ notewakeup(&work.alldone) } }